Skip to content

fix: keep RSA-OAEP-256 digest correct in runtimes that ignore oaepHash - #106

Open
mmorales-post wants to merge 3 commits into
Mastercard:mainfrom
mmorales-post:fix/oaep-digest-outside-node
Open

mmorales-post wants to merge 3 commits into
Mastercard:mainfrom
mmorales-post:fix/oaep-digest-outside-node

Conversation

@mmorales-post

@mmorales-post mmorales-post commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

PR checklist

  • An issue/feature request has been created for this PR
  • Pull Request title clearly describes the work in the pull request and the Pull Request description provides details about how to validate the work
  • File the PR against the master branch
  • The code in this PR is covered by unit tests

Link to issue/feature request: #105

Description

The JWE key wrap asked node's crypto.publicEncrypt for oaepHash: "sha256". node
honours that; the crypto-browserify polyfill silently ignores it and wraps with SHA-1,
producing a token whose header claims RSA-OAEP-256. It fails only at the recipient.

node callers are unaffected. The native path is retained and selected by a one-time
probe. Measured 0.055 ms against 0.050 ms on main for encryptData, within noise.

The probe. node validates the digest name and throws on an unrecognised one; a
polyfill that ignores oaepHash accepts anything. Attempting a wrap under a deliberately
bogus digest therefore distinguishes them, costs one call, and is memoised. It also treats
an absent or stubbed crypto module as unsupported, which is what
resolve.fallback.crypto = false in this repo's webpack.config.js produces.

The fallback goes through node-forge with SHA-256 set for the OAEP digest, which
forge otherwise defaults to SHA-1. MGF1 follows that digest when unset; it is set
explicitly anyway to make the intent clear. The helper mirrors createOAEPOptions
in field-level-crypto.js. Forge keys are parsed lazily, so node callers never parse them.

decryptData gets the same treatment, so a browser build can read responses as well as
send requests.

Scope. This fixes the digest, which is the failure that is silent. It does not make
the library work under resolve.fallback.crypto = false, because randomBytes,
createCipheriv and createDecipheriv still require the module. Under a bundler that
polyfills crypto the full JWE path now works. Happy to raise the remaining piece
separately if that is useful.

Validation

Three tests added to test/jwe-crypto.test.js:

  • still wraps with SHA-256 when the runtime ignores oaepHash stubs publicEncrypt to
    behave like the polyfill, then asserts the emitted key unwraps with SHA-256 and not
    SHA-1. Fails on main.
  • detects whether the runtime honours oaepHash covers the probe against real node, a
    polyfill, and an empty module. Fails on main.
  • wraps the content encryption key with RSA-OAEP-256, not SHA-1 locks the digest for the
    native path. This one passes on main as well, by design: under node the old code was
    already correct, which is why the bug survived. It is there to stop a regression.

Cross-checked that forge and native interoperate in both directions on the same key, and
that a SHA-1 unwrap of the forge output is rejected.

Full suite: 200 passing. eslint: 0 errors, 5 warnings, unchanged from main.
Validated on Node 17, 22 and 24. Probe error code verified on node 13 through 24 and 26.

@mmorales-post
mmorales-post marked this pull request as ready for review August 24, 2026 21:03
@mmorales-post
mmorales-post force-pushed the fix/oaep-digest-outside-node branch from f19a834 to ccb1501 Compare October 15, 2026 21:16

@talha-API talha-API left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for raising this PR and issue @mmorales-post and apologies for the delay in the response.

The changes are sound, just a few points to address. Please also update the PR with latest changes from main (if not done already) and add any relevant tests for the comments provided.

Comment thread lib/mcapi/crypto/jwe-crypto.js Outdated
if (
typeof nodeCrypto.publicEncrypt !== "function" ||
!nodeCrypto.constants ||
!keyPem

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

honoursOaepHash will cache false when the key is missing. The key is not related to the runtime environment.

Also, an encrypt-only instance that calls decryptData sets the module-level nativeOaepHash to false. Every JweCrypto in the process then falls back to node-forge, even on a real Node runtime, which should not be the case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The probe now always uses the encryption certificate, so a missing private key can't affect it.

Comment thread test/jwe-crypto.test.js
});
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a test covering the forge path for decryptData.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added tests for forge decrypt: tokens wrapped by node and by forge, plus a SHA-1 wrapped key that gets rejected.

Comment thread lib/mcapi/crypto/jwe-crypto.js Outdated
Buffer.alloc(1)
);
nativeOaepHash = false;
} catch {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could catch unrelated exceptions, we should probably check for a digest specific one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. It now only treats node's invalid-digest error as support. Anything else isn't cached and falls back to forge.

@talha-API talha-API left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the comments @mmorales-post , please see additional minor comments and I request you to provide some further context on the points below:

  1. Which runtime did you run into this issue in? Since the issue specifiedcrypto-browserify and public-encrypt, I assume a browser bundle. If not, please share the runtime and the bundler/versions.
  2. As you noted in the scope section, this change targets only the digest issue. A client must still polyfill process.version (≥ 13), fs, Buffer, crypto etc. How does your setup provide them?
  3. I have tested the changes and they do not introduce any regressions, but please share evidence from the setup where you found this (browser/postman/insomnia, error logs etc) It will help us review and assess the need for JWE support outside native Node.

// Accepted a digest that does not exist, so it is not using the option.
nativeOaepHash = false;
} catch (err) {
if (!err || err.code !== "ERR_OSSL_EVP_INVALID_DIGEST") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you confirm this is the same exception thrown for all node versions supported for JWE flow? i.e. Node >v12

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I ran the probe against a real certificate on every major from 13 to 24 (node:<v>-alpine), plus 26 locally. All of them throw ERR_OSSL_EVP_INVALID_DIGEST ("Invalid digest used"), on OpenSSL 1.1.1 (13 to 16) and 3.x (17 on). 13 is the floor because nodeVersionSupportsJWE requires > 12. I've added the range to the doc comment.

v13.14.0 openssl=1.1.1g       code=ERR_OSSL_EVP_INVALID_DIGEST
v14.21.3 openssl=1.1.1t       code=ERR_OSSL_EVP_INVALID_DIGEST
v15.14.0 openssl=1.1.1k+quic  code=ERR_OSSL_EVP_INVALID_DIGEST
v16.20.2 openssl=1.1.1v+quic  code=ERR_OSSL_EVP_INVALID_DIGEST
v17.9.1  openssl=3.0.3+quic   code=ERR_OSSL_EVP_INVALID_DIGEST
v18.20.8 openssl=3.0.16       code=ERR_OSSL_EVP_INVALID_DIGEST
v19.9.0  openssl=3.0.8+quic   code=ERR_OSSL_EVP_INVALID_DIGEST
v20.20.2 openssl=3.0.19       code=ERR_OSSL_EVP_INVALID_DIGEST
v22.23.4 openssl=3.5.9        code=ERR_OSSL_EVP_INVALID_DIGEST
v24.22.2 openssl=3.5.9        code=ERR_OSSL_EVP_INVALID_DIGEST

If a future release changes the code, the probe fails safe: nothing is cached and that call goes through forge.

Comment thread lib/mcapi/crypto/jwe-crypto.js Outdated
* Build node-forge OAEP options for RSA-OAEP-256.
*
* The JWE header emitted by this module is always `"alg": "RSA-OAEP-256"`, so
* the label digest and the MGF1 digest are both SHA-256. MGF1 has to be set

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the library specifies "node-forge": "^1.4.0", the claim “MGF1 has to be set explicitly or forge defaults it to SHA-1” is not entirely accurate. The thing that defaults to SHA-1 is the OAEP digest, md. MGF1 follows whatever md is.

But since explicitly setting mgf1: { md } is harmless, mirrors what FieldLevelCrypto is doing and clarifies intent, we can keep it. Please just update the comment to reflect actual behaviour.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. In forge 1.4.0 MGF1 falls back to md (pkcs1.js), and only md itself defaults to SHA-1. I've updated the comment to say that, and fixed the same claim in the PR description.

@mmorales-post

Copy link
Copy Markdown
Contributor Author

Thanks @talha-API. Comment fixes are pushed. On your three questions:

1. Runtime. The Postman script runtime, which runs the same way in the Postman app, the Postman CLI and Postman monitors. It isn't a browser, but it has the same constraints: no require('crypto'), no fs, no process, no globalThis, no window. The only crypto primitive is crypto.getRandomValues. The library has to arrive as a pre-built bundle that the script evals, which is what Mastercard's own postman-encryption-lib does (browserify 17).

The bundle I validated with: webpack 5.112.2, target: "web", crypto-browserify 3.12.1 (public-encrypt 4.0.3), buffer 6.0.3, stream-browserify 3.0.0, node-forge 1.4.0.

2. The other polyfills.

Need Provided by
crypto resolve.fallback to crypto-browserify
fs.readFileSync (cert path) alias to an in-memory virtual fs serving the PEM
process.version ProvidePlugin stub with version only. Leave out versions.node, or forge decides it's on node and calls crypto.randomBytes, which forge's own browser field stubs out
randombytes alias to a shim over crypto.getRandomValues
stream (create-hash in the publicEncrypt chain) stream-browserify
Buffer buffer, via ProvidePlugin
window window = { crypto } before the eval. forge's globalScope needs window or self off node. postman-encryption-lib does the same, window = {}; // required for postman in src/index.js

None of that needs a change to this library. The digest is the only part a polyfill can't fix: the polyfill accepts oaepHash and doesn't use it, so nothing errors at encryption time.

3. Evidence.

a) The same bundle built from main and from this branch, run in a Node vm context stripped to match the Postman script runtime (no require, process, Buffer, globalThis), encrypting with test/res/test_certificate.cert, then unwrapping the JWE encrypted_key with native node and test_key.pem:

main header={"kid":"761b003c...","cty":"application/json","alg":"RSA-OAEP-256","enc":"A256GCM"}
     sha256: ERR_OSSL_RSA_OAEP_DECODING_ERROR | sha1: unwraps
pr   header={"kid":"761b003c...","cty":"application/json","alg":"RSA-OAEP-256","enc":"A256GCM"}
     sha256: unwraps | sha1: ERR_OSSL_RSA_OAEP_DECODING_ERROR

On main, the header says RSA-OAEP-256 and the key only unwraps with SHA-1. Happy to push the harness (webpack config, shims, runner) to a gist if that helps you reproduce it.

b) I also confirmed it end to end against Mastercard's sandbox environment: the SHA-1-wrapped token from main was rejected, and the same request with a SHA-256 wrap was accepted. Happy to share the details privately if that's useful.

c) In the same bundle, in the same Postman script runtime, the field-level path is already correct, because field-level-crypto.js goes through forge with an explicit digest. Only the JWE path depends on native crypto.

On the need for JWE outside native node: postman-encryption-lib uses this library for field-level encryption but brings in node-jose for JWE, so Postman users today run two implementations of the scheme. With this fix one library covers both, which is the outcome we want on our side as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants